commit 1ae7f759ad07989dd999cb4b7b149db7acb591a4 Author: Vinlic Date: Sun Mar 17 15:56:53 2024 +0800 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d525fd0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +logs +dist +doc +node_modules +.vscode +.git +.gitignore +README.md +*.tar.gz \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..a2901a0 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,46 @@ +name: Build and Push Docker Image + +on: + release: + types: [created] + workflow_dispatch: + inputs: + tag: + description: 'Tag Name' + required: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Set tag name + id: tag_name + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "::set-output name=tag::${GITHUB_REF#refs/tags/}" + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "::set-output name=tag::${{ github.event.inputs.tag }}" + fi + + - name: Build and push Docker image with Release tag + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + tags: | + vinlic/glm-free-api:${{ steps.tag_name.outputs.tag }} + vinlic/glm-free-api:latest + platforms: linux/amd64,linux/arm64 + build-args: TARGETPLATFORM=${{ matrix.platform }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..103a106 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +logs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..220adf8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:lts AS BUILD_IMAGE + +WORKDIR /app + +COPY . /app + +RUN npm i --registry http://registry.npmmirror.com && npm run build + +FROM node:lts-alpine + +COPY --from=BUILD_IMAGE /app/configs ./configs +COPY --from=BUILD_IMAGE /app/package.json ./package.json +COPY --from=BUILD_IMAGE /app/dist ./dist +COPY --from=BUILD_IMAGE /app/node_modules ./node_modules + +WORKDIR /app + +EXPOSE 8000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6398c3 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# GLM AI Free 服务 + +![](https://img.shields.io/github/license/llm-red-team/glm-free-api.svg) +![](https://img.shields.io/github/stars/llm-red-team/glm-free-api.svg) +![](https://img.shields.io/github/forks/llm-red-team/glm-free-api.svg) +![](https://img.shields.io/docker/pulls/vinlic/glm-free-api.svg) + +支持高速流式输出、支持多轮对话、支持智能体对话、支持AI绘图、支持联网搜索、支持长文档解读、支持图像解析,零配置部署,多路token支持,自动清理会话痕迹。 + +与ChatGPT接口完全兼容。 + +## 声明 + +仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担! + +仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担! + +仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担! + diff --git a/configs/dev/service.yml b/configs/dev/service.yml new file mode 100644 index 0000000..c3b2e40 --- /dev/null +++ b/configs/dev/service.yml @@ -0,0 +1,6 @@ +# 服务名称 +name: glm-free-api +# 服务绑定主机地址 +host: '0.0.0.0' +# 服务绑定端口 +port: 8000 \ No newline at end of file diff --git a/configs/dev/system.yml b/configs/dev/system.yml new file mode 100644 index 0000000..dca6170 --- /dev/null +++ b/configs/dev/system.yml @@ -0,0 +1,14 @@ +# 是否开启请求日志 +requestLog: true +# 临时目录路径 +tmpDir: ./tmp +# 日志目录路径 +logDir: ./logs +# 日志写入间隔(毫秒) +logWriteInterval: 200 +# 日志文件有效期(毫秒) +logFileExpires: 2626560000 +# 公共目录路径 +publicDir: ./public +# 临时文件有效期(毫秒) +tmpFileExpires: 86400000 \ No newline at end of file diff --git a/libs.d.ts b/libs.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1568638 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "glm-free-api", + "version": "0.0.1", + "description": "GLM Free API Server", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "directories": { + "dist": "dist" + }, + "files": [ + "dist/" + ], + "scripts": { + "dev": "tsup src/index.ts --format cjs,esm --sourcemap --dts --publicDir public --watch --onSuccess \"node dist/index.js\"", + "start": "node dist/index.js", + "build": "tsup src/index.ts --format cjs,esm --sourcemap --dts --clean --publicDir public" + }, + "author": "Vinlic", + "license": "ISC", + "dependencies": { + "axios": "^1.6.7", + "colors": "^1.4.0", + "crc-32": "^1.2.2", + "cron": "^3.1.6", + "date-fns": "^3.3.1", + "eventsource-parser": "^1.1.2", + "form-data": "^4.0.0", + "fs-extra": "^11.2.0", + "koa": "^2.15.0", + "koa-body": "^5.0.0", + "koa-bodyparser": "^4.4.1", + "koa-range": "^0.3.0", + "koa-router": "^12.0.1", + "koa2-cors": "^2.0.6", + "lodash": "^4.17.21", + "mime": "^4.0.1", + "minimist": "^1.2.8", + "randomstring": "^1.3.0", + "uuid": "^9.0.1", + "yaml": "^2.3.4" + }, + "devDependencies": { + "@types/lodash": "^4.14.202", + "@types/mime": "^3.0.4", + "tsup": "^8.0.2", + "typescript": "^5.3.3" + } +} diff --git a/src/api/consts/exceptions.ts b/src/api/consts/exceptions.ts new file mode 100644 index 0000000..812e463 --- /dev/null +++ b/src/api/consts/exceptions.ts @@ -0,0 +1,9 @@ +export default { + API_TEST: [-9999, 'API异常错误'], + API_REQUEST_PARAMS_INVALID: [-2000, '请求参数非法'], + API_REQUEST_FAILED: [-2001, '请求失败'], + API_TOKEN_EXPIRES: [-2002, 'Token已失效'], + API_FILE_URL_INVALID: [-2003, '远程文件URL非法'], + API_FILE_EXECEEDS_SIZE: [-2004, '远程文件超出大小'], + API_CHAT_STREAM_PUSHING: [-2005, '已有对话流正在输出'] +} \ No newline at end of file diff --git a/src/api/controllers/chat.ts b/src/api/controllers/chat.ts new file mode 100644 index 0000000..4380cdf --- /dev/null +++ b/src/api/controllers/chat.ts @@ -0,0 +1,672 @@ +import { PassThrough } from "stream"; +import path from 'path'; +import _ from 'lodash'; +import mime from 'mime'; +import FormData from "form-data"; +import axios, { AxiosResponse } from 'axios'; + +import APIException from "@/lib/exceptions/APIException.ts"; +import EX from "@/api/consts/exceptions.ts"; +import { createParser } from 'eventsource-parser' +import logger from '@/lib/logger.ts'; +import util from '@/lib/util.ts'; + +// 模型名称 +const MODEL_NAME = 'glm'; +// 默认的智能体ID +const DEFAULT_ASSISTANT_ID = '65c32fa2a766a337422a6006'; +// access_token有效期 +const ACCESS_TOKEN_EXPIRES = 3600; +// 最大重试次数 +const MAX_RETRY_COUNT = 3; +// 重试延迟 +const RETRY_DELAY = 5000; +// 伪装headers +const FAKE_HEADERS = { + 'Accept': '*/*', + 'App-Name': 'chatglm', + 'Platform': 'pc', + 'Origin': 'https://chatglm.cn', + 'Sec-Ch-Ua': '"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + 'Version': '0.0.1' +}; +// 文件最大大小 +const FILE_MAX_SIZE = 100 * 1024 * 1024; +// access_token映射 +const accessTokenMap = new Map(); +// access_token请求队列映射 +const accessTokenRequestQueueMap: Record = {}; + +/** + * 请求access_token + * + * 使用refresh_token去刷新获得access_token + * + * @param refreshToken 用于刷新access_token的refresh_token + */ +async function requestToken(refreshToken: string) { + if (accessTokenRequestQueueMap[refreshToken]) + return new Promise(resolve => accessTokenRequestQueueMap[refreshToken].push(resolve)); + accessTokenRequestQueueMap[refreshToken] = []; + logger.info(`Refresh token: ${refreshToken}`); + const result = await (async () => { + const result = await axios.post('https://chatglm.cn/chatglm/backend-api/v1/user/refresh', {}, { + headers: { + Authorization: `Bearer ${refreshToken}`, + Referer: 'https://chatglm.cn/main/alltoolsdetail', + 'X-Device-Id': util.uuid(false), + 'X-Request-Id': util.uuid(false), + ...FAKE_HEADERS + }, + timeout: 15000, + validateStatus: () => true + }); + const { result: _result } = checkResult(result, refreshToken); + const { accessToken } = _result; + return { + accessToken, + refreshToken, + refreshTime: util.unixTimestamp() + ACCESS_TOKEN_EXPIRES + } + })() + .then(result => { + if(accessTokenRequestQueueMap[refreshToken]) { + accessTokenRequestQueueMap[refreshToken].forEach(resolve => resolve(result)); + delete accessTokenRequestQueueMap[refreshToken]; + } + logger.success(`Refresh successful`); + return result; + }) + .catch(err => { + if(accessTokenRequestQueueMap[refreshToken]) { + accessTokenRequestQueueMap[refreshToken].forEach(resolve => resolve(err)); + delete accessTokenRequestQueueMap[refreshToken]; + } + return err; + }); + if(_.isError(result)) + throw result; + return result; +} + +/** + * 获取缓存中的access_token + * + * 避免短时间大量刷新token,未加锁,如果有并发要求还需加锁 + * + * @param refreshToken 用于刷新access_token的refresh_token + */ +async function acquireToken(refreshToken: string): Promise { + let result = accessTokenMap.get(refreshToken); + if (!result) { + result = await requestToken(refreshToken); + accessTokenMap.set(refreshToken, result); + } + if (util.unixTimestamp() > result.refreshTime) { + result = await requestToken(refreshToken); + accessTokenMap.set(refreshToken, result); + } + return result.accessToken; +} + +/** + * 移除会话 + * + * 在对话流传输完毕后移除会话,避免创建的会话出现在用户的对话列表中 + * + * @param refreshToken 用于刷新access_token的refresh_token + */ +async function removeConversation(convId: string, refreshToken: string, assistantId = DEFAULT_ASSISTANT_ID) { + const token = await acquireToken(refreshToken); + + const result = await axios.post('https://chatglm.cn/chatglm/backend-api/assistant/conversation/delete', { + assistant_id: assistantId, + conversation_id: convId + }, { + headers: { + Authorization: `Bearer ${token}`, + Referer: `https://chatglm.cn/main/alltoolsdetail`, + 'X-Device-Id': util.uuid(false), + 'X-Request-Id': util.uuid(false), + ...FAKE_HEADERS + }, + timeout: 15000, + validateStatus: () => true + }); + checkResult(result, refreshToken); +} + +/** + * 同步对话补全 + * + * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 + * @param refreshToken 用于刷新access_token的refresh_token + * @param assistantId 智能体ID,默认使用GLM4原版 + * @param retryCount 重试次数 + */ +async function createCompletion(messages: any[], refreshToken: string, assistantId = DEFAULT_ASSISTANT_ID, retryCount = 0) { + return (async () => { + logger.info(messages); + + // 提取引用文件URL并上传获得引用的文件ID列表 + const refFileUrls = extractRefFileUrls(messages); + const refs = refFileUrls.length ? await Promise.all(refFileUrls.map(fileUrl => uploadFile(fileUrl, refreshToken))) : []; + + // 请求流 + const token = await acquireToken(refreshToken); + console.log({ + assistant_id: assistantId, + conversation_id: '', + messages: messagesPrepare(messages, refs), + meta_data: { + channel: '', + draft_id: '', + input_question_type: 'xxxx', + is_test: false + } + }); + const result = await axios.post('https://chatglm.cn/chatglm/backend-api/assistant/stream', { + assistant_id: assistantId, + conversation_id: '', + messages: messagesPrepare(messages, refs), + meta_data: { + channel: '', + draft_id: '', + input_question_type: 'xxxx', + is_test: false + } + }, { + headers: { + Authorization: `Bearer ${token}`, + Referer: assistantId == DEFAULT_ASSISTANT_ID ? 'https://chatglm.cn/main/alltoolsdetail' : `https://chatglm.cn/main/gdetail/${assistantId}`, + 'X-Device-Id': util.uuid(false), + 'X-Request-Id': util.uuid(false), + ...FAKE_HEADERS + }, + // 120秒超时 + timeout: 120000, + validateStatus: () => true, + responseType: 'stream' + }); + + const streamStartTime = util.timestamp(); + // 接收流为输出文本 + const answer = await receiveStream(result.data); + logger.success(`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`); + + // 异步移除会话 + removeConversation(answer.id, refreshToken, assistantId) + .catch(err => console.error(err)); + + return answer; + })() + .catch(err => { + if(retryCount < MAX_RETRY_COUNT) { + logger.error(`Stream response error: ${err.stack}`); + logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); + return (async () => { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + return createCompletion(messages, refreshToken, assistantId, retryCount + 1); + })(); + } + throw err; + }); +} + +/** + * 流式对话补全 + * + * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 + * @param refreshToken 用于刷新access_token的refresh_token + * @param assistantId 智能体ID,默认使用GLM4原版 + * @param retryCount 重试次数 + */ +async function createCompletionStream(messages: any[], refreshToken: string, assistantId = DEFAULT_ASSISTANT_ID, retryCount = 0) { + return (async () => { + logger.info(messages); + + // 提取引用文件URL并上传获得引用的文件ID列表 + const refFileUrls = extractRefFileUrls(messages); + const refs = refFileUrls.length ? await Promise.all(refFileUrls.map(fileUrl => uploadFile(fileUrl, refreshToken))) : []; + + // 请求流 + const token = await acquireToken(refreshToken); + const result = await axios.post(`https://chatglm.cn/chatglm/backend-api/assistant/stream`, { + assistant_id: assistantId, + conversation_id: '', + messages: messagesPrepare(messages, refs), + meta_data: { + channel: '', + draft_id: '', + input_question_type: 'xxxx', + is_test: false + } + }, { + headers: { + Authorization: `Bearer ${token}`, + Referer: assistantId == DEFAULT_ASSISTANT_ID ? 'https://chatglm.cn/main/alltoolsdetail' : `https://chatglm.cn/main/gdetail/${assistantId}`, + 'X-Device-Id': util.uuid(false), + 'X-Request-Id': util.uuid(false), + ...FAKE_HEADERS + }, + // 120秒超时 + timeout: 120000, + validateStatus: () => true, + responseType: 'stream' + }); + + const streamStartTime = util.timestamp(); + // 创建转换流将消息格式转换为gpt兼容格式 + return createTransStream(result.data, (convId: string) => { + logger.success(`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`); + // 流传输结束后异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略 + removeConversation(convId, refreshToken, assistantId) + .catch(err => console.error(err)); + }); + })() + .catch(err => { + if(retryCount < MAX_RETRY_COUNT) { + logger.error(`Stream response error: ${err.stack}`); + logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); + return (async () => { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + return createCompletionStream(messages, refreshToken, assistantId, retryCount + 1); + })(); + } + throw err; + }); +} + +/** + * 提取消息中引用的文件URL + * + * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 + */ +function extractRefFileUrls(messages: any[]) { + return messages.reduce((urls, message) => { + if (_.isArray(message.content)) { + message.content.forEach(v => { + if (!_.isObject(v) || !['file', 'image_url'].includes(v['type'])) + return; + // glm-free-api支持格式 + if (v['type'] == 'file' && _.isObject(v['file_url']) && _.isString(v['file_url']['url'])) + urls.push(v['file_url']['url']); + // 兼容gpt-4-vision-preview API格式 + else if (v['type'] == 'image_url' && _.isObject(v['image_url']) && _.isString(v['image_url']['url'])) + urls.push(v['image_url']['url']); + }); + } + return urls; + }, []); +} + +/** + * 消息预处理 + * + * 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果 + * 设定:【重要】回答用户问题,不要输出说话者\n + * 我:旧消息1 + * ChatGLM:旧消息2 + * 我:新消息 + * + * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 + */ +function messagesPrepare(messages: any[], refs: any[]) { + const headPrompt = '设定:【重要】回答用户问题,不要输出说话者\n'; + const content = messages.reduce((content, message) => { + if (_.isArray(message.content)) { + return message.content.reduce((_content, v) => { + if (!_.isObject(v) || v['type'] != 'text') + return _content; + return _content + (v['text'] || ''); + }, content); + } + const role = message.role.replace('user', '我').replace('assistant', 'ChatGLM') || '我'; + return content += `${role}:${message.content}\n`; + }, ''); + return [ + { + role: 'user', + content: [ + { type: 'text', text: headPrompt + content }, + ...(refs.length == 0 ? [] : [{ + type: 'file', + file: refs + }]) + ] + } + ] +} + +/** + * 预检查文件URL有效性 + * + * @param fileUrl 文件URL + */ +async function checkFileUrl(fileUrl: string) { + if (util.isBASE64Data(fileUrl)) + return; + const result = await axios.head(fileUrl, { + timeout: 15000, + validateStatus: () => true + }); + if (result.status >= 400) + throw new APIException(EX.API_FILE_URL_INVALID, `File ${fileUrl} is not valid: [${result.status}] ${result.statusText}`); + // 检查文件大小 + if (result.headers && result.headers['content-length']) { + const fileSize = parseInt(result.headers['content-length'], 10); + if (fileSize > FILE_MAX_SIZE) + throw new APIException(EX.API_FILE_EXECEEDS_SIZE, `File ${fileUrl} is not valid`); + } +} + +/** + * 上传文件 + * + * @param fileUrl 文件URL + * @param refreshToken 用于刷新access_token的refresh_token + */ +async function uploadFile(fileUrl: string, refreshToken: string) { + // 预检查远程文件URL可用性 + await checkFileUrl(fileUrl); + + let filename, fileData, mimeType; + // 如果是BASE64数据则直接转换为Buffer + if (util.isBASE64Data(fileUrl)) { + mimeType = util.extractBASE64DataFormat(fileUrl); + const ext = mime.getExtension(mimeType); + filename = `${util.uuid()}.${ext}`; + fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), 'base64'); + } + // 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存 + else { + filename = path.basename(fileUrl); + ({ data: fileData } = await axios.get(fileUrl, { + responseType: 'arraybuffer', + // 100M限制 + maxContentLength: FILE_MAX_SIZE, + // 60秒超时 + timeout: 60000 + })); + } + + // 获取文件的MIME类型 + mimeType = mimeType || mime.getType(filename); + + const formData = new FormData(); + formData.append('file', fileData, { + filename, + contentType: mimeType + }); + + // 上传文件到目标OSS + const token = await acquireToken(refreshToken); + let result = await axios.request({ + method: 'POST', + url: 'https://chatglm.cn/chatglm/backend-api/assistant/file_upload', + data: formData, + // 100M限制 + maxBodyLength: FILE_MAX_SIZE, + // 60秒超时 + timeout: 60000, + headers: { + Authorization: `Bearer ${token}`, + Referer: `https://chatglm.cn/`, + ...FAKE_HEADERS, + ...formData.getHeaders() + }, + validateStatus: () => true + }); + const { result: uploadResult } = checkResult(result, refreshToken); + + return uploadResult; +} + +/** + * 检查请求结果 + * + * @param result 结果 + */ +function checkResult(result: AxiosResponse, refreshToken: string) { + if (!result.data) + return null; + const { code, status, message } = result.data; + if (!_.isFinite(code) && !_.isFinite(status)) + return result.data; + if (code === 0 || status === 0) + return result.data; + if (code == 401) + accessTokenMap.delete(refreshToken); + throw new APIException(EX.API_REQUEST_FAILED, `[请求glm失败]: ${message}`); +} + +/** + * 从流接收完整的消息内容 + * + * @param stream 消息流 + */ +async function receiveStream(stream: any): Promise { + return new Promise((resolve, reject) => { + // 消息初始化 + const data = { + id: '', + model: MODEL_NAME, + object: 'chat.completion', + choices: [ + { index: 0, message: { role: 'assistant', content: '' }, finish_reason: 'stop' } + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + created: util.unixTimestamp() + }; + let toolCall = false; + let textOffset = 0; + const parser = createParser(event => { + try { + if (event.type !== "event") return; + // 解析JSON + const result = _.attempt(() => JSON.parse(event.data)); + if (_.isError(result)) + throw new Error(`Stream response invalid: ${event.data}`); + if(!data.id && result.conversation_id) + data.id = result.conversation_id; + if(result.status != 'finish') { + const text = result.parts.reduce((str, part) => { + const { status, content, meta_data } = part; + if(!_.isArray(content)) + return str; + const partText = content.reduce((innerStr, value) => { + const { type, text, image } = value; + if(type == 'text') { + if(toolCall) { + innerStr += '\n'; + textOffset++; + toolCall = false; + } + return innerStr + text; + } + else if(type == 'quote_result' && status == 'finish' && meta_data && _.isArray(meta_data.metadata_list)) { + const searchText = meta_data.metadata_list.reduce((meta, v) => meta + `检索 ${v.title}(${v.url}) ...`, '') + '\n'; + textOffset += searchText.length; + toolCall = true; + return innerStr + searchText; + } + else if(type == 'image' && _.isArray(image) && status == 'finish') { + const imageText = image.reduce((imgs, v) => imgs + `![图像](${v.image_url || ''})`, '') + '\n'; + textOffset += imageText.length; + toolCall = true; + return innerStr + imageText; + } + return innerStr; + }, ''); + return str + partText; + }, ''); + const chunk = text.substring(data.choices[0].message.content.length - textOffset, text.length); + data.choices[0].message.content += chunk; + } + else { + data.choices[0].message.content = data.choices[0].message.content.replace(/【\d+†source】/g, ''); + resolve(data); + } + } + catch (err) { + logger.error(err); + reject(err); + } + }); + // 将流数据喂给SSE转换器 + stream.on("data", buffer => parser.feed(buffer.toString())); + stream.once("error", err => reject(err)); + stream.once("close", () => resolve(data)); + }); +} + +/** + * 创建转换流 + * + * 将流格式转换为gpt兼容流格式 + * + * @param stream 消息流 + * @param endCallback 传输结束回调 + */ +function createTransStream(stream: any, endCallback?: Function) { + // 消息创建时间 + const created = util.unixTimestamp(); + // 创建转换流 + const transStream = new PassThrough(); + let content = ''; + let toolCall = false; + let textOffset = 0; + let sourceTagCheck = false; + let outputTemp = ''; + !transStream.closed && transStream.write(`data: ${JSON.stringify({ + id: '', + model: MODEL_NAME, + object: 'chat.completion.chunk', + choices: [ + { index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null } + ], + created + })}\n\n`); + const parser = createParser(event => { + try { + if (event.type !== "event") return; + // 解析JSON + const result = _.attempt(() => JSON.parse(event.data)); + if (_.isError(result)) + throw new Error(`Stream response invalid: ${event.data}`); + if(result.status != 'finish') { + const text = result.parts.reduce((str, part) => { + const { status, content, meta_data } = part; + if(!_.isArray(content)) + return str; + const partText = content.reduce((innerStr, value) => { + const { type, text, image } = value; + if(type == 'text') { + if(toolCall) { + innerStr += '\n'; + textOffset++; + toolCall = false; + } + return innerStr + text.replace(/【\d+†source】/g, ''); + } + else if(type == 'quote_result' && status == 'finish' && meta_data && _.isArray(meta_data.metadata_list)) { + const searchText = meta_data.metadata_list.reduce((meta, v) => meta + `检索 ${v.title}(${v.url}) ...`, '') + '\n'; + textOffset += searchText.length; + toolCall = true; + return innerStr + searchText; + } + else if(type == 'image' && _.isArray(image) && status == 'finish') { + const imageText = image.reduce((imgs, v) => imgs + `![图像](${v.image_url || ''})`, '') + '\n'; + textOffset += imageText.length; + toolCall = true; + return innerStr + imageText; + } + return innerStr; + }, ''); + return str + partText; + }, ''); + const chunk = text.substring(content.length - textOffset, text.length); + if(chunk) { + content += chunk; + const data = `data: ${JSON.stringify({ + id: result.conversation_id, + model: MODEL_NAME, + object: 'chat.completion.chunk', + choices: [ + { index: 0, delta: { content: chunk }, finish_reason: null } + ], + created + })}\n\n`; + !transStream.closed && transStream.write(data); + } + } + else { + const data = `data: ${JSON.stringify({ + id: result.conversation_id, + model: MODEL_NAME, + object: 'chat.completion.chunk', + choices: [ + { + index: 0, delta: {}, finish_reason: 'stop' + } + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + created + })}\n\n`; + !transStream.closed && transStream.write(data); + !transStream.closed && transStream.end('data: [DONE]\n\n'); + content = ''; + endCallback && endCallback(); + } + } + catch (err) { + logger.error(err); + !transStream.closed && transStream.end('\n\n'); + } + }); + // 将流数据喂给SSE转换器 + stream.on("data", buffer => parser.feed(buffer.toString())); + stream.once("error", () => !transStream.closed && transStream.end('data: [DONE]\n\n')); + stream.once("close", () => !transStream.closed && transStream.end('data: [DONE]\n\n')); + return transStream; +} + +/** + * Token切分 + * + * @param authorization 认证字符串 + */ +function tokenSplit(authorization: string) { + return authorization.replace('Bearer ', '').split(','); +} + +/** + * 备用生成cookie + * + * 暂时还不需要 + * + * @param refreshToken + * @param token + */ +function generateCookie(refreshToken: string, token: string) { + const timestamp = util.unixTimestamp(); + const gsTimestamp = timestamp - Math.round(Math.random() * 2592000); + return { + chatglm_refresh_token: refreshToken, + // chatglm_user_id: '', + _ga_PMD05MS2V9: `GS1.1.${gsTimestamp}.18.0.${gsTimestamp}.0.0.0`, + chatglm_token: token, + chatglm_token_expires: util.getDateString('yyyy-MM-dd HH:mm:ss'), + abtestid: 'a', + // acw_tc: '' + } +} + +export default { + createCompletion, + createCompletionStream, + tokenSplit +}; diff --git a/src/api/routes/chat.ts b/src/api/routes/chat.ts new file mode 100644 index 0000000..5f2346b --- /dev/null +++ b/src/api/routes/chat.ts @@ -0,0 +1,35 @@ +import _ from 'lodash'; + +import Request from '@/lib/request/Request.ts'; +import Response from '@/lib/response/Response.ts'; +import chat from '@/api/controllers/chat.ts'; +import logger from '@/lib/logger.ts'; + +export default { + + prefix: '/v1/chat', + + post: { + + '/completions': async (request: Request) => { + request + .validate('body.messages', _.isArray) + .validate('headers.authorization', _.isString) + // refresh_token切分 + const tokens = chat.tokenSplit(request.headers.authorization); + // 随机挑选一个refresh_token + const token = _.sample(tokens); + const messages = request.body.messages; + if (request.body.stream) { + const stream = await chat.createCompletionStream(request.body.messages, token, request.body.use_search); + return new Response(stream, { + type: "text/event-stream" + }); + } + else + return await chat.createCompletion(messages, token, request.body.use_search); + } + + } + +} \ No newline at end of file diff --git a/src/api/routes/index.ts b/src/api/routes/index.ts new file mode 100644 index 0000000..0bf9ca9 --- /dev/null +++ b/src/api/routes/index.ts @@ -0,0 +1,5 @@ +import chat from "./chat.ts"; + +export default [ + chat +]; \ No newline at end of file diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..0c5fe69 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,82 @@ +/** + * 守护进程 + */ + +import process from 'process'; +import path from 'path'; +import { spawn } from 'child_process'; + +import fs from 'fs-extra'; +import { format as dateFormat } from 'date-fns'; +import 'colors'; + +const CRASH_RESTART_LIMIT = 600; //进程崩溃重启次数限制 +const CRASH_RESTART_DELAY = 5000; //进程崩溃重启延迟 +const LOG_PATH = path.resolve("./logs/daemon.log"); //守护进程日志路径 +let crashCount = 0; //进程崩溃次数 +let currentProcess; //当前运行进程 + +/** + * 写入守护进程日志 + */ +function daemonLog(value, color?: string) { + try { + const head = `[daemon][${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")}] `; + value = head + value; + console.log(color ? value[color] : value); + fs.ensureDirSync(path.dirname(LOG_PATH)); + fs.appendFileSync(LOG_PATH, value + "\n"); + } + catch(err) { + console.error("daemon log write error:", err); + } +} + +daemonLog(`daemon pid: ${process.pid}`); + +function createProcess() { + const childProcess = spawn("node", ["index.js", ...process.argv.slice(2)]); //启动子进程 + childProcess.stdout.pipe(process.stdout, { end: false }); //将子进程输出管道到当前进程输出 + childProcess.stderr.pipe(process.stderr, { end: false }); //将子进程错误输出管道到当前进程输出 + currentProcess = childProcess; //更新当前进程 + daemonLog(`process(${childProcess.pid}) has started`); + childProcess.on("error", err => daemonLog(`process(${childProcess.pid}) error: ${err.stack}`, "red")); + childProcess.on("close", code => { + if(code === 0) //进程正常退出 + daemonLog(`process(${childProcess.pid}) has exited`); + else if(code === 2) //进程已被杀死 + daemonLog(`process(${childProcess.pid}) has been killed!`, "bgYellow"); + else if(code === 3) { //进程主动重启 + daemonLog(`process(${childProcess.pid}) has restart`, "yellow"); + createProcess(); //重新创建进程 + } + else { //进程发生崩溃 + if(crashCount++ < CRASH_RESTART_LIMIT) { //进程崩溃次数未达重启次数上限前尝试重启 + daemonLog(`process(${childProcess.pid}) has crashed! delay ${CRASH_RESTART_DELAY}ms try restarting...(${crashCount})`, "bgRed"); + setTimeout(() => createProcess(), CRASH_RESTART_DELAY); //延迟指定时长后再重启 + } + else //进程已崩溃,且无法重启 + daemonLog(`process(${childProcess.pid}) has crashed! unable to restart`, "bgRed"); + } + }); //子进程关闭监听 +} + +process.on("exit", code => { + if(code === 0) + daemonLog("daemon process exited"); + else if(code === 2) + daemonLog("daemon process has been killed!"); +}); //守护进程退出事件 + +process.on("SIGTERM", () => { + daemonLog("received kill signal", "yellow"); + currentProcess && currentProcess.kill("SIGINT"); + process.exit(2); +}); //kill退出守护进程 + +process.on("SIGINT", () => { + currentProcess && currentProcess.kill("SIGINT"); + process.exit(0); +}); //主动退出守护进程 + +createProcess(); //创建进程 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..60a0e91 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,32 @@ +"use strict"; + +import environment from "@/lib/environment.ts"; +import config from "@/lib/config.ts"; +import "@/lib/initialize.ts"; +import server from "@/lib/server.ts"; +import routes from "@/api/routes/index.ts"; +import logger from "@/lib/logger.ts"; + +const startupTime = performance.now(); + +(async () => { + logger.header(); + + logger.info("<<<< glm free server >>>>"); + logger.info("Version:", environment.package.version); + logger.info("Process id:", process.pid); + logger.info("Environment:", environment.env); + logger.info("Service name:", config.service.name); + + server.attachRoutes(routes); + await server.listen(); + + config.service.bindAddress && + logger.success("Service bind address:", config.service.bindAddress); +})() + .then(() => + logger.success( + `Service startup completed (${Math.floor(performance.now() - startupTime)}ms)` + ) + ) + .catch((err) => console.error(err)); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..b6072d2 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,14 @@ +import serviceConfig from "./configs/service-config.ts"; +import systemConfig from "./configs/system-config.ts"; + +class Config { + + /** 服务配置 */ + service = serviceConfig; + + /** 系统配置 */ + system = systemConfig; + +} + +export default new Config(); \ No newline at end of file diff --git a/src/lib/configs/service-config.ts b/src/lib/configs/service-config.ts new file mode 100644 index 0000000..9618d42 --- /dev/null +++ b/src/lib/configs/service-config.ts @@ -0,0 +1,68 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import yaml from 'yaml'; +import _ from 'lodash'; + +import environment from '../environment.ts'; +import util from '../util.ts'; + +const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/service.yml"); + +/** + * 服务配置 + */ +export class ServiceConfig { + + /** 服务名称 */ + name: string; + /** @type {string} 服务绑定主机地址 */ + host; + /** @type {number} 服务绑定端口 */ + port; + /** @type {string} 服务路由前缀 */ + urlPrefix; + /** @type {string} 服务绑定地址(外部访问地址) */ + bindAddress; + + constructor(options?: any) { + const { name, host, port, urlPrefix, bindAddress } = options || {}; + this.name = _.defaultTo(name, 'glm-free-api'); + this.host = _.defaultTo(host, '0.0.0.0'); + this.port = _.defaultTo(port, 5566); + this.urlPrefix = _.defaultTo(urlPrefix, ''); + this.bindAddress = bindAddress; + } + + get addressHost() { + if(this.bindAddress) return this.bindAddress; + const ipAddresses = util.getIPAddressesByIPv4(); + for(let ipAddress of ipAddresses) { + if(ipAddress === this.host) + return ipAddress; + } + return ipAddresses[0] || "127.0.0.1"; + } + + get address() { + return `${this.addressHost}:${this.port}`; + } + + get pageDirUrl() { + return `http://127.0.0.1:${this.port}/page`; + } + + get publicDirUrl() { + return `http://127.0.0.1:${this.port}/public`; + } + + static load() { + const external = _.pickBy(environment, (v, k) => ["name", "host", "port"].includes(k) && !_.isUndefined(v)); + if(!fs.pathExistsSync(CONFIG_PATH)) return new ServiceConfig(external); + const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString()); + return new ServiceConfig({ ...data, ...external }); + } + +} + +export default ServiceConfig.load(); \ No newline at end of file diff --git a/src/lib/configs/system-config.ts b/src/lib/configs/system-config.ts new file mode 100644 index 0000000..7c589a6 --- /dev/null +++ b/src/lib/configs/system-config.ts @@ -0,0 +1,84 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import yaml from 'yaml'; +import _ from 'lodash'; + +import environment from '../environment.ts'; + +const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/system.yml"); + +/** + * 系统配置 + */ +export class SystemConfig { + + /** 是否开启请求日志 */ + requestLog: boolean; + /** 临时目录路径 */ + tmpDir: string; + /** 日志目录路径 */ + logDir: string; + /** 日志写入间隔(毫秒) */ + logWriteInterval: number; + /** 日志文件有效期(毫秒) */ + logFileExpires: number; + /** 公共目录路径 */ + publicDir: string; + /** 临时文件有效期(毫秒) */ + tmpFileExpires: number; + /** 请求体配置 */ + requestBody: any; + /** 是否调试模式 */ + debug: boolean; + + constructor(options?: any) { + const { requestLog, tmpDir, logDir, logWriteInterval, logFileExpires, publicDir, tmpFileExpires, requestBody, debug } = options || {}; + this.requestLog = _.defaultTo(requestLog, false); + this.tmpDir = _.defaultTo(tmpDir, './tmp'); + this.logDir = _.defaultTo(logDir, './logs'); + this.logWriteInterval = _.defaultTo(logWriteInterval, 200); + this.logFileExpires = _.defaultTo(logFileExpires, 2626560000); + this.publicDir = _.defaultTo(publicDir, './public'); + this.tmpFileExpires = _.defaultTo(tmpFileExpires, 86400000); + this.requestBody = Object.assign(requestBody || {}, { + enableTypes: ['json', 'form', 'text', 'xml'], + encoding: 'utf-8', + formLimit: '100mb', + jsonLimit: '100mb', + textLimit: '100mb', + xmlLimit: '100mb', + formidable: { + maxFileSize: '100mb' + }, + multipart: true, + parsedMethods: ['POST', 'PUT', 'PATCH'] + }); + this.debug = _.defaultTo(debug, true); + } + + get rootDirPath() { + return path.resolve(); + } + + get tmpDirPath() { + return path.resolve(this.tmpDir); + } + + get logDirPath() { + return path.resolve(this.logDir); + } + + get publicDirPath() { + return path.resolve(this.publicDir); + } + + static load() { + if (!fs.pathExistsSync(CONFIG_PATH)) return new SystemConfig(); + const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString()); + return new SystemConfig(data); + } + +} + +export default SystemConfig.load(); \ No newline at end of file diff --git a/src/lib/consts/exceptions.ts b/src/lib/consts/exceptions.ts new file mode 100644 index 0000000..7a9b788 --- /dev/null +++ b/src/lib/consts/exceptions.ts @@ -0,0 +1,5 @@ +export default { + SYSTEM_ERROR: [-1000, '系统异常'], + SYSTEM_REQUEST_VALIDATION_ERROR: [-1001, '请求参数校验错误'], + SYSTEM_NOT_ROUTE_MATCHING: [-1002, '无匹配的路由'] +} as Record \ No newline at end of file diff --git a/src/lib/environment.ts b/src/lib/environment.ts new file mode 100644 index 0000000..6e52a84 --- /dev/null +++ b/src/lib/environment.ts @@ -0,0 +1,44 @@ +import path from 'path'; + +import fs from 'fs-extra'; +import minimist from 'minimist'; +import _ from 'lodash'; + +const cmdArgs = minimist(process.argv.slice(2)); //获取命令行参数 +const envVars = process.env; //获取环境变量 + +class Environment { + + /** 命令行参数 */ + cmdArgs: any; + /** 环境变量 */ + envVars: any; + /** 环境名称 */ + env?: string; + /** 服务名称 */ + name?: string; + /** 服务地址 */ + host?: string; + /** 服务端口 */ + port?: number; + /** 包参数 */ + package: any; + + constructor(options: any = {}) { + const { cmdArgs, envVars, package: _package } = options; + this.cmdArgs = cmdArgs; + this.envVars = envVars; + this.env = _.defaultTo(cmdArgs.env || envVars.SERVER_ENV, 'dev'); + this.name = cmdArgs.name || envVars.SERVER_NAME || undefined; + this.host = cmdArgs.host || envVars.SERVER_HOST || undefined; + this.port = Number(cmdArgs.port || envVars.SERVER_PORT) ? Number(cmdArgs.port || envVars.SERVER_PORT) : undefined; + this.package = _package; + } + +} + +export default new Environment({ + cmdArgs, + envVars, + package: JSON.parse(fs.readFileSync(path.join(path.resolve(), "package.json")).toString()) +}); \ No newline at end of file diff --git a/src/lib/exceptions/APIException.ts b/src/lib/exceptions/APIException.ts new file mode 100644 index 0000000..515c806 --- /dev/null +++ b/src/lib/exceptions/APIException.ts @@ -0,0 +1,14 @@ +import Exception from './Exception.js'; + +export default class APIException extends Exception { + + /** + * 构造异常 + * + * @param {[number, string]} exception 异常 + */ + constructor(exception: (string | number)[], errmsg?: string) { + super(exception, errmsg); + } + +} \ No newline at end of file diff --git a/src/lib/exceptions/Exception.ts b/src/lib/exceptions/Exception.ts new file mode 100644 index 0000000..ef0372f --- /dev/null +++ b/src/lib/exceptions/Exception.ts @@ -0,0 +1,47 @@ +import assert from 'assert'; + +import _ from 'lodash'; + +export default class Exception extends Error { + + /** 错误码 */ + errcode: number; + /** 错误消息 */ + errmsg: string; + /** 数据 */ + data: any; + /** HTTP状态码 */ + httpStatusCode: number; + + /** + * 构造异常 + * + * @param exception 异常 + * @param _errmsg 异常消息 + */ + constructor(exception: (string | number)[], _errmsg?: string) { + assert(_.isArray(exception), 'Exception must be Array'); + const [errcode, errmsg] = exception as [number, string]; + assert(_.isFinite(errcode), 'Exception errcode invalid'); + assert(_.isString(errmsg), 'Exception errmsg invalid'); + super(_errmsg || errmsg); + this.errcode = errcode; + this.errmsg = _errmsg || errmsg; + } + + compare(exception: (string | number)[]) { + const [errcode] = exception as [number, string]; + return this.errcode == errcode; + } + + setHTTPStatusCode(value: number) { + this.httpStatusCode = value; + return this; + } + + setData(value: any) { + this.data = _.defaultTo(value, null); + return this; + } + +} \ No newline at end of file diff --git a/src/lib/http-status-codes.ts b/src/lib/http-status-codes.ts new file mode 100644 index 0000000..cc0c571 --- /dev/null +++ b/src/lib/http-status-codes.ts @@ -0,0 +1,61 @@ +export default { + + CONTINUE: 100, //客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。服务器必须在请求完成后向客户端发送一个最终响应 + SWITCHING_PROTOCOLS: 101, //服务器已经理解了客户端的请求,并将通过Upgrade 消息头通知客户端采用不同的协议来完成这个请求。在发送完这个响应最后的空行后,服务器将会切换到在Upgrade 消息头中定义的那些协议。只有在切换新的协议更有好处的时候才应该采取类似措施。例如,切换到新的HTTP 版本比旧版本更有优势,或者切换到一个实时且同步的协议以传送利用此类特性的资源 + PROCESSING: 102, //处理将被继续执行 + + OK: 200, //请求已成功,请求所希望的响应头或数据体将随此响应返回 + CREATED: 201, //请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,且其 URI 已经随Location 头信息返回。假如需要的资源无法及时建立的话,应当返回 '202 Accepted' + ACCEPTED: 202, //服务器已接受请求,但尚未处理。正如它可能被拒绝一样,最终该请求可能会也可能不会被执行。在异步操作的场合下,没有比发送这个状态码更方便的做法了。返回202状态码的响应的目的是允许服务器接受其他过程的请求(例如某个每天只执行一次的基于批处理的操作),而不必让客户端一直保持与服务器的连接直到批处理操作全部完成。在接受请求处理并返回202状态码的响应应当在返回的实体中包含一些指示处理当前状态的信息,以及指向处理状态监视器或状态预测的指针,以便用户能够估计操作是否已经完成 + NON_AUTHORITATIVE_INFO: 203, //服务器已成功处理了请求,但返回的实体头部元信息不是在原始服务器上有效的确定集合,而是来自本地或者第三方的拷贝。当前的信息可能是原始版本的子集或者超集。例如,包含资源的元数据可能导致原始服务器知道元信息的超级。使用此状态码不是必须的,而且只有在响应不使用此状态码便会返回200 OK的情况下才是合适的 + NO_CONTENT: 204, //服务器成功处理了请求,但不需要返回任何实体内容,并且希望返回更新了的元信息。响应可能通过实体头部的形式,返回新的或更新后的元信息。如果存在这些头部信息,则应当与所请求的变量相呼应。如果客户端是浏览器的话,那么用户浏览器应保留发送了该请求的页面,而不产生任何文档视图上的变化,即使按照规范新的或更新后的元信息应当被应用到用户浏览器活动视图中的文档。由于204响应被禁止包含任何消息体,因此它始终以消息头后的第一个空行结尾 + RESET_CONTENT: 205, //服务器成功处理了请求,且没有返回任何内容。但是与204响应不同,返回此状态码的响应要求请求者重置文档视图。该响应主要是被用于接受用户输入后,立即重置表单,以便用户能够轻松地开始另一次输入。与204响应一样,该响应也被禁止包含任何消息体,且以消息头后的第一个空行结束 + PARTIAL_CONTENT: 206, //服务器已经成功处理了部分 GET 请求。类似于FlashGet或者迅雷这类的HTTP下载工具都是使用此类响应实现断点续传或者将一个大文档分解为多个下载段同时下载。该请求必须包含 Range 头信息来指示客户端希望得到的内容范围,并且可能包含 If-Range 来作为请求条件。响应必须包含如下的头部域:Content-Range 用以指示本次响应中返回的内容的范围;如果是Content-Type为multipart/byteranges的多段下载,则每一段multipart中都应包含Content-Range域用以指示本段的内容范围。假如响应中包含Content-Length,那么它的数值必须匹配它返回的内容范围的真实字节数。Date和ETag或Content-Location,假如同样的请求本应该返回200响应。Expires, Cache-Control,和/或 Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了 If-Range 强缓存验证,那么本次响应不应该包含其他实体头;假如本响应的请求使用了 If-Range 弱缓存验证,那么本次响应禁止包含其他实体头;这避免了缓存的实体内容和更新了的实体头信息之间的不一致。否则,本响应就应当包含所有本应该返回200响应中应当返回的所有实体头部域。假如 ETag 或 Latest-Modified 头部不能精确匹配的话,则客户端缓存应禁止将206响应返回的内容与之前任何缓存过的内容组合在一起。任何不支持 Range 以及 Content-Range 头的缓存都禁止缓存206响应返回的内容 + MULTIPLE_STATUS: 207, //代表之后的消息体将是一个XML消息,并且可能依照之前子请求数量的不同,包含一系列独立的响应代码 + + MULTIPLE_CHOICES: 300, //被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。除非这是一个HEAD请求,否则该响应应当包括一个资源特性及地址的列表的实体,以便用户或浏览器从中选择最合适的重定向地址。这个实体的格式由Content-Type定义的格式所决定。浏览器可能根据响应的格式以及浏览器自身能力,自动作出最合适的选择。当然,RFC 2616规范并没有规定这样的自动选择该如何进行。如果服务器本身已经有了首选的回馈选择,那么在Location中应当指明这个回馈的 URI;浏览器可能会将这个 Location 值作为自动重定向的地址。此外,除非额外指定,否则这个响应也是可缓存的 + MOVED_PERMANENTLY: 301, //被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个URI之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。除非额外指定,否则这个响应也是可缓存的。新的永久性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,因此浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:对于某些使用 HTTP/1.0 协议的浏览器,当它们发送的POST请求得到了一个301响应的话,接下来的重定向请求将会变成GET方式 + FOUND: 302, //请求的资源现在临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI应当在响应的 Location 域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:虽然RFC 1945和RFC 2068规范不允许客户端在重定向时改变请求的方法,但是很多现存的浏览器将302响应视作为303响应,并且使用GET方式访问在Location中规定的URI,而无视原先请求的方法。状态码303和307被添加了进来,用以明确服务器期待客户端进行何种反应 + SEE_OTHER: 303, //对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用 GET 的方式访问那个资源。这个方法的存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源。这个新的 URI 不是原始资源的替代引用。同时,303响应禁止被缓存。当然,第二个请求(重定向)可能被缓存。新的 URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。注意:许多 HTTP/1.1 版以前的浏览器不能正确理解303状态。如果需要考虑与这些浏览器之间的互动,302状态码应该可以胜任,因为大多数的浏览器处理302响应时的方式恰恰就是上述规范要求客户端处理303响应时应当做的 + NOT_MODIFIED: 304, //如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变,则服务器应当返回这个状态码。304响应禁止包含消息体,因此始终以消息头后的第一个空行结尾。该响应必须包含以下的头信息:Date,除非这个服务器没有时钟。假如没有时钟的服务器也遵守这些规则,那么代理服务器以及客户端可以自行将Date字段添加到接收到的响应头中去(正如RFC 2068中规定的一样),缓存机制将会正常工作。ETag或 Content-Location,假如同样的请求本应返回200响应。Expires, Cache-Control,和/或Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了强缓存验证,那么本次响应不应该包含其他实体头;否则(例如,某个带条件的 GET 请求使用了弱缓存验证),本次响应禁止包含其他实体头;这避免了缓存了的实体内容和更新了的实体头信息之间的不一致。假如某个304响应指明了当前某个实体没有缓存,那么缓存系统必须忽视这个响应,并且重复发送不包含限制条件的请求。假如接收到一个要求更新某个缓存条目的304响应,那么缓存系统必须更新整个条目以反映所有在响应中被更新的字段的值 + USE_PROXY: 305, //被请求的资源必须通过指定的代理才能被访问。Location域中将给出指定的代理所在的URI信息,接收者需要重复发送一个单独的请求,通过这个代理才能访问相应资源。只有原始服务器才能建立305响应。注意:RFC 2068中没有明确305响应是为了重定向一个单独的请求,而且只能被原始服务器建立。忽视这些限制可能导致严重的安全后果 + UNUSED: 306, //在最新版的规范中,306状态码已经不再被使用 + TEMPORARY_REDIRECT: 307, //请求的资源现在临时从不同的URI 响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI 的超链接及简短说明。因为部分浏览器不能识别307响应,因此需要添加上述必要信息以便用户能够理解并向新的 URI 发出访问请求。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化 + + BAD_REQUEST: 400, //1.语义有误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求 2.请求参数有误 + UNAUTHORIZED: 401, //当前请求需要用户验证。该响应必须包含一个适用于被请求资源的 WWW-Authenticate 信息头用以询问用户信息。客户端可以重复提交一个包含恰当的 Authorization 头信息的请求。如果当前请求已经包含了 Authorization 证书,那么401响应代表着服务器验证已经拒绝了那些证书。如果401响应包含了与前一个响应相同的身份验证询问,且浏览器已经至少尝试了一次验证,那么浏览器应当向用户展示响应中包含的实体信息,因为这个实体信息中可能包含了相关诊断信息。参见RFC 2617 + PAYMENT_REQUIRED: 402, //该状态码是为了将来可能的需求而预留的 + FORBIDDEN: 403, //服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交。如果这不是一个HEAD请求,而且服务器希望能够讲清楚为何请求不能被执行,那么就应该在实体内描述拒绝的原因。当然服务器也可以返回一个404响应,假如它不希望让客户端获得任何信息 + NOT_FOUND: 404, //请求失败,请求所希望得到的资源未被在服务器上发现。没有信息能够告诉用户这个状况到底是暂时的还是永久的。假如服务器知道情况的话,应当使用410状态码来告知旧资源因为某些内部的配置机制问题,已经永久的不可用,而且没有任何可以跳转的地址。404这个状态码被广泛应用于当服务器不想揭示到底为何请求被拒绝或者没有其他适合的响应可用的情况下 + METHOD_NOT_ALLOWED: 405, //请求行中指定的请求方法不能被用于请求相应的资源。该响应必须返回一个Allow 头信息用以表示出当前资源能够接受的请求方法的列表。鉴于PUT,DELETE方法会对服务器上的资源进行写操作,因而绝大部分的网页服务器都不支持或者在默认配置下不允许上述请求方法,对于此类请求均会返回405错误 + NO_ACCEPTABLE: 406, //请求的资源的内容特性无法满足请求头中的条件,因而无法生成响应实体。除非这是一个 HEAD 请求,否则该响应就应当返回一个包含可以让用户或者浏览器从中选择最合适的实体特性以及地址列表的实体。实体的格式由Content-Type头中定义的媒体类型决定。浏览器可以根据格式及自身能力自行作出最佳选择。但是,规范中并没有定义任何作出此类自动选择的标准 + PROXY_AUTHENTICATION_REQUIRED: 407, //与401响应类似,只不过客户端必须在代理服务器上进行身份验证。代理服务器必须返回一个Proxy-Authenticate用以进行身份询问。客户端可以返回一个Proxy-Authorization信息头用以验证。参见RFC 2617 + REQUEST_TIMEOUT: 408, //请求超时。客户端没有在服务器预备等待的时间内完成一个请求的发送。客户端可以随时再次提交这一请求而无需进行任何更改 + CONFLICT: 409, //由于和被请求的资源的当前状态之间存在冲突,请求无法完成。这个代码只允许用在这样的情况下才能被使用:用户被认为能够解决冲突,并且会重新提交新的请求。该响应应当包含足够的信息以便用户发现冲突的源头。冲突通常发生于对PUT请求的处理中。例如,在采用版本检查的环境下,某次PUT提交的对特定资源的修改请求所附带的版本信息与之前的某个(第三方)请求向冲突,那么此时服务器就应该返回一个409错误,告知用户请求无法完成。此时,响应实体中很可能会包含两个冲突版本之间的差异比较,以便用户重新提交归并以后的新版本 + GONE: 410, //被请求的资源在服务器上已经不再可用,而且没有任何已知的转发地址。这样的状况应当被认为是永久性的。如果可能,拥有链接编辑功能的客户端应当在获得用户许可后删除所有指向这个地址的引用。如果服务器不知道或者无法确定这个状况是否是永久的,那么就应该使用404状态码。除非额外说明,否则这个响应是可缓存的。410响应的目的主要是帮助网站管理员维护网站,通知用户该资源已经不再可用,并且服务器拥有者希望所有指向这个资源的远端连接也被删除。这类事件在限时、增值服务中很普遍。同样,410响应也被用于通知客户端在当前服务器站点上,原本属于某个个人的资源已经不再可用。当然,是否需要把所有永久不可用的资源标记为'410 Gone',以及是否需要保持此标记多长时间,完全取决于服务器拥有者 + LENGTH_REQUIRED: 411, //服务器拒绝在没有定义Content-Length头的情况下接受请求。在添加了表明请求消息体长度的有效Content-Length头之后,客户端可以再次提交该请求 + PRECONDITION_FAILED: 412, //服务器在验证在请求的头字段中给出先决条件时,没能满足其中的一个或多个。这个状态码允许客户端在获取资源时在请求的元信息(请求头字段数据)中设置先决条件,以此避免该请求方法被应用到其希望的内容以外的资源上 + REQUEST_ENTITY_TOO_LARGE: 413, //服务器拒绝处理当前请求,因为该请求提交的实体数据大小超过了服务器愿意或者能够处理的范围。此种情况下,服务器可以关闭连接以免客户端继续发送此请求。如果这个状况是临时的,服务器应当返回一个 Retry-After 的响应头,以告知客户端可以在多少时间以后重新尝试 + REQUEST_URI_TOO_LONG: 414, //请求的URI长度超过了服务器能够解释的长度,因此服务器拒绝对该请求提供服务。这比较少见,通常的情况包括:本应使用POST方法的表单提交变成了GET方法,导致查询字符串(Query String)过长。重定向URI “黑洞”,例如每次重定向把旧的URI作为新的URI的一部分,导致在若干次重定向后URI超长。客户端正在尝试利用某些服务器中存在的安全漏洞攻击服务器。这类服务器使用固定长度的缓冲读取或操作请求的URI,当GET后的参数超过某个数值后,可能会产生缓冲区溢出,导致任意代码被执行[1]。没有此类漏洞的服务器,应当返回414状态码 + UNSUPPORTED_MEDIA_TYPE: 415, //对于当前请求的方法和所请求的资源,请求中提交的实体并不是服务器中所支持的格式,因此请求被拒绝 + REQUESTED_RANGE_NOT_SATISFIABLE: 416, //如果请求中包含了Range请求头,并且Range中指定的任何数据范围都与当前资源的可用范围不重合,同时请求中又没有定义If-Range请求头,那么服务器就应当返回416状态码。假如Range使用的是字节范围,那么这种情况就是指请求指定的所有数据范围的首字节位置都超过了当前资源的长度。服务器也应当在返回416状态码的同时,包含一个Content-Range实体头,用以指明当前资源的长度。这个响应也被禁止使用multipart/byteranges作为其 Content-Type + EXPECTION_FAILED: 417, //在请求头Expect中指定的预期内容无法被服务器满足,或者这个服务器是一个代理服务器,它有明显的证据证明在当前路由的下一个节点上,Expect的内容无法被满足 + TOO_MANY_CONNECTIONS: 421, //从当前客户端所在的IP地址到服务器的连接数超过了服务器许可的最大范围。通常,这里的IP地址指的是从服务器上看到的客户端地址(比如用户的网关或者代理服务器地址)。在这种情况下,连接数的计算可能涉及到不止一个终端用户 + UNPROCESSABLE_ENTITY: 422, //请求格式正确,但是由于含有语义错误,无法响应 + FAILED_DEPENDENCY: 424, //由于之前的某个请求发生的错误,导致当前请求失败,例如PROPPATCH + UNORDERED_COLLECTION: 425, //在WebDav Advanced Collections 草案中定义,但是未出现在《WebDAV 顺序集协议》(RFC 3658)中 + UPGRADE_REQUIRED: 426, //客户端应当切换到TLS/1.0 + RETRY_WITH: 449, //由微软扩展,代表请求应当在执行完适当的操作后进行重试 + + INTERNAL_SERVER_ERROR: 500, //服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。一般来说,这个问题都会在服务器的程序码出错时出现 + NOT_IMPLEMENTED: 501, //服务器不支持当前请求所需要的某个功能。当服务器无法识别请求的方法,并且无法支持其对任何资源的请求 + BAD_GATEWAY: 502, //作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应 + SERVICE_UNAVAILABLE: 503, //由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。如果能够预计延迟时间,那么响应中可以包含一个 Retry-After 头用以标明这个延迟时间。如果没有给出这个 Retry-After 信息,那么客户端应当以处理500响应的方式处理它。注意:503状态码的存在并不意味着服务器在过载的时候必须使用它。某些服务器只不过是希望拒绝客户端的连接 + GATEWAY_TIMEOUT: 504, //作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器(URI标识出的服务器,例如HTTP、FTP、LDAP)或者辅助服务器(例如DNS)收到响应。注意:某些代理服务器在DNS查询超时时会返回400或者500错误 + HTTP_VERSION_NOT_SUPPORTED: 505, //服务器不支持,或者拒绝支持在请求中使用的HTTP版本。这暗示着服务器不能或不愿使用与客户端相同的版本。响应中应当包含一个描述了为何版本不被支持以及服务器支持哪些协议的实体 + VARIANT_ALSO_NEGOTIATES: 506, //服务器存在内部配置错误:被请求的协商变元资源被配置为在透明内容协商中使用自己,因此在一个协商处理中不是一个合适的重点 + INSUFFICIENT_STORAGE: 507, //服务器无法存储完成请求所必须的内容。这个状况被认为是临时的 + BANDWIDTH_LIMIT_EXCEEDED: 509, //服务器达到带宽限制。这不是一个官方的状态码,但是仍被广泛使用 + NOT_EXTENDED: 510 //获取资源所需要的策略并没有没满足 + +}; \ No newline at end of file diff --git a/src/lib/initialize.ts b/src/lib/initialize.ts new file mode 100644 index 0000000..953d224 --- /dev/null +++ b/src/lib/initialize.ts @@ -0,0 +1,28 @@ +import logger from './logger.js'; + +// 允许无限量的监听器 +process.setMaxListeners(Infinity); +// 输出未捕获异常 +process.on("uncaughtException", (err, origin) => { + logger.error(`An unhandled error occurred: ${origin}`, err); +}); +// 输出未处理的Promise.reject +process.on("unhandledRejection", (_, promise) => { + promise.catch(err => logger.error("An unhandled rejection occurred:", err)); +}); +// 输出系统警告信息 +process.on("warning", warning => logger.warn("System warning: ", warning)); +// 进程退出监听 +process.on("exit", () => { + logger.info("Service exit"); + logger.footer(); +}); +// 进程被kill +process.on("SIGTERM", () => { + logger.warn("received kill signal"); + process.exit(2); +}); +// Ctrl-C进程退出 +process.on("SIGINT", () => { + process.exit(0); +}); \ No newline at end of file diff --git a/src/lib/interfaces/ICompletionMessage.ts b/src/lib/interfaces/ICompletionMessage.ts new file mode 100644 index 0000000..5aad345 --- /dev/null +++ b/src/lib/interfaces/ICompletionMessage.ts @@ -0,0 +1,4 @@ +export default interface ICompletionMessage { + role: 'system' | 'assistant' | 'user' | 'function'; + content: string; +} \ No newline at end of file diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..e41494d --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,182 @@ +import path from 'path'; +import _util from 'util'; + +import 'colors'; +import _ from 'lodash'; +import fs from 'fs-extra'; +import { format as dateFormat } from 'date-fns'; + +import config from './config.ts'; +import util from './util.ts'; + +class LogWriter { + + #buffers = []; + + constructor() { + fs.ensureDirSync(config.system.logDirPath); + this.work(); + } + + push(content) { + const buffer = Buffer.from(content); + this.#buffers.push(buffer); + } + + writeSync(buffer) { + fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer); + } + + async write(buffer) { + await fs.appendFile(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer); + } + + flush() { + if(!this.#buffers.length) return; + fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), Buffer.concat(this.#buffers)); + } + + work() { + if (!this.#buffers.length) return setTimeout(this.work.bind(this), config.system.logWriteInterval); + const buffer = Buffer.concat(this.#buffers); + this.#buffers = []; + this.write(buffer) + .finally(() => setTimeout(this.work.bind(this), config.system.logWriteInterval)) + .catch(err => console.error("Log write error:", err)); + } + +} + +class LogText { + + /** @type {string} 日志级别 */ + level; + /** @type {string} 日志文本 */ + text; + /** @type {string} 日志来源 */ + source; + /** @type {Date} 日志发生时间 */ + time = new Date(); + + constructor(level, ...params) { + this.level = level; + this.text = _util.format.apply(null, params); + this.source = this.#getStackTopCodeInfo(); + } + + #getStackTopCodeInfo() { + const unknownInfo = { name: "unknown", codeLine: 0, codeColumn: 0 }; + const stackArray = new Error().stack.split("\n"); + const text = stackArray[4]; + if (!text) + return unknownInfo; + const match = text.match(/at (.+) \((.+)\)/) || text.match(/at (.+)/); + if (!match || !_.isString(match[2] || match[1])) + return unknownInfo; + const temp = match[2] || match[1]; + const _match = temp.match(/([a-zA-Z0-9_\-\.]+)\:(\d+)\:(\d+)$/); + if (!_match) + return unknownInfo; + const [, scriptPath, codeLine, codeColumn] = _match as any; + return { + name: scriptPath ? scriptPath.replace(/.js$/, "") : "unknown", + path: scriptPath || null, + codeLine: parseInt(codeLine || 0), + codeColumn: parseInt(codeColumn || 0) + }; + } + + toString() { + return `[${dateFormat(this.time, "yyyy-MM-dd HH:mm:ss.SSS")}][${this.level}][${this.source.name}<${this.source.codeLine},${this.source.codeColumn}>] ${this.text}`; + } + +} + +class Logger { + + /** @type {Object} 系统配置 */ + config = {}; + /** @type {Object} 日志级别映射 */ + static Level = { + Success: "success", + Info: "info", + Log: "log", + Debug: "debug", + Warning: "warning", + Error: "error", + Fatal: "fatal" + }; + /** @type {Object} 日志级别文本颜色樱色 */ + static LevelColor = { + [Logger.Level.Success]: "green", + [Logger.Level.Info]: "brightCyan", + [Logger.Level.Debug]: "white", + [Logger.Level.Warning]: "brightYellow", + [Logger.Level.Error]: "brightRed", + [Logger.Level.Fatal]: "red" + }; + #writer; + + constructor() { + this.#writer = new LogWriter(); + } + + header() { + this.#writer.writeSync(Buffer.from(`\n\n===================== LOG START ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`)); + } + + footer() { + this.#writer.flush(); //将未写入文件的日志缓存写入 + this.#writer.writeSync(Buffer.from(`\n\n===================== LOG END ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`)); + } + + success(...params) { + const content = new LogText(Logger.Level.Success, ...params).toString(); + console.info(content[Logger.LevelColor[Logger.Level.Success]]); + this.#writer.push(content + "\n"); + } + + info(...params) { + const content = new LogText(Logger.Level.Info, ...params).toString(); + console.info(content[Logger.LevelColor[Logger.Level.Info]]); + this.#writer.push(content + "\n"); + } + + log(...params) { + const content = new LogText(Logger.Level.Log, ...params).toString(); + console.log(content[Logger.LevelColor[Logger.Level.Log]]); + this.#writer.push(content + "\n"); + } + + debug(...params) { + if(!config.system.debug) return; //非调试模式忽略debug + const content = new LogText(Logger.Level.Debug, ...params).toString(); + console.debug(content[Logger.LevelColor[Logger.Level.Debug]]); + this.#writer.push(content + "\n"); + } + + warn(...params) { + const content = new LogText(Logger.Level.Warning, ...params).toString(); + console.warn(content[Logger.LevelColor[Logger.Level.Warning]]); + this.#writer.push(content + "\n"); + } + + error(...params) { + const content = new LogText(Logger.Level.Error, ...params).toString(); + console.error(content[Logger.LevelColor[Logger.Level.Error]]); + this.#writer.push(content); + } + + fatal(...params) { + const content = new LogText(Logger.Level.Fatal, ...params).toString(); + console.error(content[Logger.LevelColor[Logger.Level.Fatal]]); + this.#writer.push(content); + } + + destory() { + this.#writer.destory(); + } + +} + +export default new Logger(); \ No newline at end of file diff --git a/src/lib/request/Request.ts b/src/lib/request/Request.ts new file mode 100644 index 0000000..fce6045 --- /dev/null +++ b/src/lib/request/Request.ts @@ -0,0 +1,72 @@ +import _ from 'lodash'; + +import APIException from '@/lib/exceptions/APIException.ts'; +import EX from '@/api/consts/exceptions.ts'; +import logger from '@/lib/logger.ts'; +import util from '@/lib/util.ts'; + +export interface RequestOptions { + time?: number; +} + +export default class Request { + + /** 请求方法 */ + method: string; + /** 请求URL */ + url: string; + /** 请求路径 */ + path: string; + /** 请求载荷类型 */ + type: string; + /** 请求headers */ + headers: any; + /** 请求原始查询字符串 */ + search: string; + /** 请求查询参数 */ + query: any; + /** 请求URL参数 */ + params: any; + /** 请求载荷 */ + body: any; + /** 上传的文件 */ + files: any[]; + /** 客户端IP地址 */ + remoteIP: string | null; + /** 请求接受时间戳(毫秒) */ + time: number; + + constructor(ctx, options: RequestOptions = {}) { + const { time } = options; + this.method = ctx.request.method; + this.url = ctx.request.url; + this.path = ctx.request.path; + this.type = ctx.request.type; + this.headers = ctx.request.headers || {}; + this.search = ctx.request.search; + this.query = ctx.query || {}; + this.params = ctx.params || {}; + this.body = ctx.request.body || {}; + this.files = ctx.request.files || {}; + this.remoteIP = this.headers["X-Real-IP"] || this.headers["x-real-ip"] || this.headers["X-Forwarded-For"] || this.headers["x-forwarded-for"] || ctx.ip || null; + this.time = Number(_.defaultTo(time, util.timestamp())); + } + + validate(key: string, fn?: Function) { + try { + const value = _.get(this, key); + if (fn) { + if (fn(value) === false) + throw `[Mismatch] -> ${fn}`; + } + else if (_.isUndefined(value)) + throw '[Undefined]'; + } + catch (err) { + logger.warn(`Params ${key} invalid:`, err); + throw new APIException(EX.API_REQUEST_PARAMS_INVALID, `Params ${key} invalid`); + } + return this; + } + +} \ No newline at end of file diff --git a/src/lib/response/Body.ts b/src/lib/response/Body.ts new file mode 100644 index 0000000..9cf8574 --- /dev/null +++ b/src/lib/response/Body.ts @@ -0,0 +1,41 @@ +import _ from 'lodash'; + +export interface BodyOptions { + code?: number; + message?: string; + data?: any; + statusCode?: number; +} + +export default class Body { + + /** 状态码 */ + code: number; + /** 状态消息 */ + message: string; + /** 载荷 */ + data: any; + /** HTTP状态码 */ + statusCode: number; + + constructor(options: BodyOptions = {}) { + const { code, message, data, statusCode } = options; + this.code = Number(_.defaultTo(code, 0)); + this.message = _.defaultTo(message, 'OK'); + this.data = _.defaultTo(data, null); + this.statusCode = Number(_.defaultTo(statusCode, 200)); + } + + toObject() { + return { + code: this.code, + message: this.message, + data: this.data + }; + } + + static isInstance(value) { + return value instanceof Body; + } + +} \ No newline at end of file diff --git a/src/lib/response/FailureBody.ts b/src/lib/response/FailureBody.ts new file mode 100644 index 0000000..1f66417 --- /dev/null +++ b/src/lib/response/FailureBody.ts @@ -0,0 +1,31 @@ +import _ from 'lodash'; + +import Body from './Body.ts'; +import Exception from '../exceptions/Exception.ts'; +import APIException from '../exceptions/APIException.ts'; +import EX from '../consts/exceptions.ts'; +import HTTP_STATUS_CODES from '../http-status-codes.ts'; + +export default class FailureBody extends Body { + + constructor(error: APIException | Exception | Error, _data?: any) { + let errcode, errmsg, data = _data, httpStatusCode = HTTP_STATUS_CODES.OK;; + if(_.isString(error)) + error = new Exception(EX.SYSTEM_ERROR, error); + else if(error instanceof APIException || error instanceof Exception) + ({ errcode, errmsg, data, httpStatusCode } = error); + else if(_.isError(error)) + error = new Exception(EX.SYSTEM_ERROR, error.message); + super({ + code: errcode || -1, + message: errmsg || 'Internal error', + data, + statusCode: httpStatusCode + }); + } + + static isInstance(value) { + return value instanceof FailureBody; + } + +} \ No newline at end of file diff --git a/src/lib/response/Response.ts b/src/lib/response/Response.ts new file mode 100644 index 0000000..816397d --- /dev/null +++ b/src/lib/response/Response.ts @@ -0,0 +1,63 @@ +import mime from 'mime'; +import _ from 'lodash'; + +import Body from './Body.ts'; +import util from '../util.ts'; + +export interface ResponseOptions { + statusCode?: number; + type?: string; + headers?: Record; + redirect?: string; + body?: any; + size?: number; + time?: number; +} + +export default class Response { + + /** 响应HTTP状态码 */ + statusCode: number; + /** 响应内容类型 */ + type: string; + /** 响应headers */ + headers: Record; + /** 重定向目标 */ + redirect: string; + /** 响应载荷 */ + body: any; + /** 响应载荷大小 */ + size: number; + /** 响应时间戳 */ + time: number; + + constructor(body: any, options: ResponseOptions = {}) { + const { statusCode, type, headers, redirect, size, time } = options; + this.statusCode = Number(_.defaultTo(statusCode, Body.isInstance(body) ? body.statusCode : undefined)) + this.type = type; + this.headers = headers; + this.redirect = redirect; + this.size = size; + this.time = Number(_.defaultTo(time, util.timestamp())); + this.body = body; + } + + injectTo(ctx) { + this.redirect && ctx.redirect(this.redirect); + this.statusCode && (ctx.status = this.statusCode); + this.type && (ctx.type = mime.getType(this.type) || this.type); + const headers = this.headers || {}; + if(this.size && !headers["Content-Length"] && !headers["content-length"]) + headers["Content-Length"] = this.size; + ctx.set(headers); + if(Body.isInstance(this.body)) + ctx.body = this.body.toObject(); + else + ctx.body = this.body; + } + + static isInstance(value) { + return value instanceof Response; + } + +} \ No newline at end of file diff --git a/src/lib/response/SuccessfulBody.ts b/src/lib/response/SuccessfulBody.ts new file mode 100644 index 0000000..639d0d8 --- /dev/null +++ b/src/lib/response/SuccessfulBody.ts @@ -0,0 +1,19 @@ +import _ from 'lodash'; + +import Body from './Body.ts'; + +export default class SuccessfulBody extends Body { + + constructor(data: any, message?: string) { + super({ + code: 0, + message: _.defaultTo(message, "OK"), + data + }); + } + + static isInstance(value) { + return value instanceof SuccessfulBody; + } + +} \ No newline at end of file diff --git a/src/lib/server.ts b/src/lib/server.ts new file mode 100644 index 0000000..a8645e8 --- /dev/null +++ b/src/lib/server.ts @@ -0,0 +1,169 @@ +import Koa from 'koa'; +import KoaRouter from 'koa-router'; +import koaRange from 'koa-range'; +import koaCors from "koa2-cors"; +import koaBody from 'koa-body'; +import _ from 'lodash'; + +import Exception from './exceptions/Exception.ts'; +import Request from './request/Request.ts'; +import Response from './response/Response.js'; +import FailureBody from './response/FailureBody.ts'; +import EX from './consts/exceptions.ts'; +import logger from './logger.ts'; +import config from './config.ts'; + +class Server { + + app; + router; + + constructor() { + this.app = new Koa(); + this.app.use(koaCors()); + // 范围请求支持 + this.app.use(koaRange); + this.router = new KoaRouter({ prefix: config.service.urlPrefix }); + // 前置处理异常拦截 + this.app.use(async (ctx: any, next: Function) => { + if(ctx.request.type === "application/xml" || ctx.request.type === "application/ssml+xml") + ctx.req.headers["content-type"] = "text/xml"; + try { await next() } + catch (err) { + logger.error(err); + const failureBody = new FailureBody(err); + new Response(failureBody).injectTo(ctx); + } + }); + // 载荷解析器支持 + this.app.use(koaBody(_.clone(config.system.requestBody))); + this.app.on("error", (err: any) => { + // 忽略连接重试、中断、管道、取消错误 + if (["ECONNRESET", "ECONNABORTED", "EPIPE", "ECANCELED"].includes(err.code)) return; + logger.error(err); + }); + logger.success("Server initialized"); + } + + /** + * 附加路由 + * + * @param routes 路由列表 + */ + attachRoutes(routes: any[]) { + routes.forEach((route: any) => { + const prefix = route.prefix || ""; + for (let method in route) { + if(method === "prefix") continue; + if (!_.isObject(route[method])) { + logger.warn(`Router ${prefix} ${method} invalid`); + continue; + } + for (let uri in route[method]) { + this.router[method](`${prefix}${uri}`, async ctx => { + const { request, response } = await this.#requestProcessing(ctx, route[method][uri]); + if(response != null && config.system.requestLog) + logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`); + }); + } + } + logger.info(`Route ${config.service.urlPrefix || ""}${prefix} attached`); + }); + this.app.use(this.router.routes()); + this.app.use((ctx: any) => { + const request = new Request(ctx); + logger.debug(`-> ${ctx.request.method} ${ctx.request.url} request is not supported - ${request.remoteIP || "unknown"}`); + const failureBody = new FailureBody(new Exception(EX.SYSTEM_NOT_ROUTE_MATCHING, "Request is not supported")); + const response = new Response(failureBody); + response.injectTo(ctx); + if(config.system.requestLog) + logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`); + }); + } + + /** + * 请求处理 + * + * @param ctx 上下文 + * @param routeFn 路由方法 + */ + #requestProcessing(ctx: any, routeFn: Function): Promise { + return new Promise(resolve => { + const request = new Request(ctx); + try { + if(config.system.requestLog) + logger.info(`-> ${request.method} ${request.url}`); + routeFn(request) + .then(response => { + try { + if(!Response.isInstance(response)) { + const _response = new Response(response); + _response.injectTo(ctx); + return resolve({ request, response: _response }); + } + response.injectTo(ctx); + resolve({ request, response }); + } + catch(err) { + logger.error(err); + const failureBody = new FailureBody(err); + const response = new Response(failureBody); + response.injectTo(ctx); + resolve({ request, response }); + } + }) + .catch(err => { + try { + logger.error(err); + const failureBody = new FailureBody(err); + const response = new Response(failureBody); + response.injectTo(ctx); + resolve({ request, response }); + } + catch(err) { + logger.error(err); + const failureBody = new FailureBody(err); + const response = new Response(failureBody); + response.injectTo(ctx); + resolve({ request, response }); + } + }); + } + catch(err) { + logger.error(err); + const failureBody = new FailureBody(err); + const response = new Response(failureBody); + response.injectTo(ctx); + resolve({ request, response }); + } + }); + } + + /** + * 监听端口 + */ + async listen() { + const host = config.service.host; + const port = config.service.port; + await Promise.all([ + new Promise((resolve, reject) => { + if(host === "0.0.0.0" || host === "localhost" || host === "127.0.0.1") + return resolve(null); + this.app.listen(port, "localhost", err => { + if(err) return reject(err); + resolve(null); + }); + }), + new Promise((resolve, reject) => { + this.app.listen(port, host, err => { + if(err) return reject(err); + resolve(null); + }); + }) + ]); + logger.success(`Server listening on port ${port} (${host})`); + } + +} + +export default new Server(); \ No newline at end of file diff --git a/src/lib/util.ts b/src/lib/util.ts new file mode 100644 index 0000000..c15706c --- /dev/null +++ b/src/lib/util.ts @@ -0,0 +1,258 @@ +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; +import { Readable, Writable } from 'stream'; + +import 'colors'; +import mime from 'mime'; +import fs from 'fs-extra'; +import { v1 as uuid } from 'uuid'; +import { format as dateFormat } from 'date-fns'; +import CRC32 from 'crc-32'; +import randomstring from 'randomstring'; +import _ from 'lodash'; +import { CronJob } from 'cron'; + +import HTTP_STATUS_CODE from './http-status-codes.ts'; + +const autoIdMap = new Map(); + +const util = { + + is2DArrays(value: any) { + return _.isArray(value) && (!value[0] || (_.isArray(value[0]) && _.isArray(value[value.length - 1]))); + }, + + uuid: (separator = true) => separator ? uuid() : uuid().replace(/\-/g, ""), + + autoId: (prefix = '') => { + let index = autoIdMap.get(prefix); + if(index > 999999) index = 0; //超过最大数字则重置为0 + autoIdMap.set(prefix, (index || 0) + 1); + return `${prefix}${index || 1}`; + }, + + ignoreJSONParse(value: string) { + const result = _.attempt(() => JSON.parse(value)); + if(_.isError(result)) + return null; + return result; + }, + + generateRandomString(options: any): string { + return randomstring.generate(options); + }, + + getResponseContentType(value: any): string | null { + return value.headers ? (value.headers["content-type"] || value.headers["Content-Type"]) : null; + }, + + mimeToExtension(value: string) { + let extension = mime.getExtension(value); + if(extension == "mpga") + return "mp3"; + return extension; + }, + + extractURLExtension(value: string) { + const extname = path.extname(new URL(value).pathname); + return extname.substring(1).toLowerCase(); + }, + + createCronJob(cronPatterns: any, callback?: Function) { + if(!_.isFunction(callback)) throw new Error("callback must be an Function"); + return new CronJob(cronPatterns, () => callback(), null, false, "Asia/Shanghai"); + }, + + getDateString(format = "yyyy-MM-dd", date = new Date()) { + return dateFormat(date, format); + }, + + getIPAddressesByIPv4(): string[] { + const interfaces = os.networkInterfaces(); + const addresses = []; + for (let name in interfaces) { + const networks = interfaces[name]; + const results = networks.filter(network => network.family === "IPv4" && network.address !== "127.0.0.1" && !network.internal); + if (results[0] && results[0].address) + addresses.push(results[0].address); + } + return addresses; + }, + + getMACAddressesByIPv4(): string[] { + const interfaces = os.networkInterfaces(); + const addresses = []; + for (let name in interfaces) { + const networks = interfaces[name]; + const results = networks.filter(network => network.family === "IPv4" && network.address !== "127.0.0.1" && !network.internal); + if (results[0] && results[0].mac) + addresses.push(results[0].mac); + } + return addresses; + }, + + generateSSEData(event?: string, data?: string, retry?: number) { + return `event: ${event || "message"}\ndata: ${(data || "").replace(/\n/g, "\\n").replace(/\s/g, "\\s")}\nretry: ${retry || 3000}\n\n`; + }, + + buildDataBASE64(type, ext, buffer) { + return `data:${type}/${ext.replace("jpg", "jpeg")};base64,${buffer.toString("base64")}`; + }, + + isLinux() { + return os.platform() !== "win32"; + }, + + isIPAddress(value) { + return _.isString(value) && (/^((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)$/.test(value) || /\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*/.test(value)); + }, + + isPort(value) { + return _.isNumber(value) && value > 0 && value < 65536; + }, + + isReadStream(value): boolean { + return value && (value instanceof Readable || "readable" in value || value.readable); + }, + + isWriteStream(value): boolean { + return value && (value instanceof Writable || "writable" in value || value.writable); + }, + + isHttpStatusCode(value) { + return _.isNumber(value) && Object.values(HTTP_STATUS_CODE).includes(value); + }, + + isURL(value) { + return !_.isUndefined(value) && /^(http|https)/.test(value); + }, + + isSrc(value) { + return !_.isUndefined(value) && /^\/.+\.[0-9a-zA-Z]+(\?.+)?$/.test(value); + }, + + isBASE64(value) { + return !_.isUndefined(value) && /^[a-zA-Z0-9\/\+]+(=?)+$/.test(value); + }, + + isBASE64Data(value) { + return /^data:/.test(value); + }, + + extractBASE64DataFormat(value): string | null { + const match = value.trim().match(/^data:(.+);base64,/); + if(!match) return null; + return match[1]; + }, + + removeBASE64DataHeader(value): string { + return value.replace(/^data:(.+);base64,/, ""); + }, + + isDataString(value): boolean { + return /^(base64|json):/.test(value); + }, + + isStringNumber(value) { + return _.isFinite(Number(value)); + }, + + isUnixTimestamp(value) { + return /^[0-9]{10}$/.test(`${value}`); + }, + + isTimestamp(value) { + return /^[0-9]{13}$/.test(`${value}`); + }, + + isEmail(value) { + return /^([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+\.[a-zA-Z]{2,3}$/.test(value); + }, + + isAsyncFunction(value) { + return Object.prototype.toString.call(value) === "[object AsyncFunction]"; + }, + + async isAPNG(filePath) { + let head; + const readStream = fs.createReadStream(filePath, { start: 37, end: 40 }); + const readPromise = new Promise((resolve, reject) => { + readStream.once("end", resolve); + readStream.once("error", reject); + }); + readStream.once("data", data => head = data); + await readPromise; + return head.compare(Buffer.from([0x61, 0x63, 0x54, 0x4c])) === 0; + }, + + unixTimestamp() { + return parseInt(`${Date.now() / 1000}`); + }, + + timestamp() { + return Date.now(); + }, + + urlJoin(...values) { + let url = ""; + for (let i = 0; i < values.length; i++) + url += `${i > 0 ? "/" : ""}${values[i].replace(/^\/*/, "").replace(/\/*$/, "")}`; + return url; + }, + + millisecondsToHmss(milliseconds) { + if (_.isString(milliseconds)) return milliseconds; + milliseconds = parseInt(milliseconds); + const sec = Math.floor(milliseconds / 1000); + const hours = Math.floor(sec / 3600); + const minutes = Math.floor((sec - hours * 3600) / 60); + const seconds = sec - hours * 3600 - minutes * 60; + const ms = milliseconds % 60000 - seconds * 1000; + return `${hours > 9 ? hours : "0" + hours}:${minutes > 9 ? minutes : "0" + minutes}:${seconds > 9 ? seconds : "0" + seconds}.${ms}`; + }, + + millisecondsToTimeString(milliseconds) { + if(milliseconds < 1000) + return `${milliseconds}ms`; + if(milliseconds < 60000) + return `${parseFloat((milliseconds / 1000).toFixed(2))}s`; + return `${Math.floor(milliseconds / 1000 / 60)}m${Math.floor(milliseconds / 1000 % 60)}s`; + }, + + rgbToHex(r, g, b): string { + return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); + }, + + hexToRgb(hex) { + const value = parseInt(hex.replace(/^#/, ""), 16); + return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; + }, + + md5(value) { + return crypto.createHash("md5").update(value).digest("hex"); + }, + + crc32(value) { + return _.isBuffer(value) ? CRC32.buf(value) : CRC32.str(value); + }, + + arrayParse(value): any[] { + return _.isArray(value) ? value : [value]; + }, + + booleanParse(value) { + return value === "true" || value === true ? true : false + }, + + encodeBASE64(value) { + return Buffer.from(value).toString("base64"); + }, + + decodeBASE64(value) { + return Buffer.from(value, "base64").toString(); + }, + +}; + +export default util; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b6477c3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "noEmit": true, + "paths": { + "@/*": ["src/*"] + }, + "outDir": "./dist" + }, + "include": ["src/**/*", "libs.d.ts"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file